I denne artikkelen skal vi se på inn- og utsiden av en I/O-reaktor og hvordan den fungerer, skrive en implementering på mindre enn 200 linjer med kode, og lage en enkel HTTP-serverprosess over 40 millioner forespørsler/min.
Forord
Artikkelen ble skrevet for å hjelpe til med å forstå funksjonen til I/O-reaktoren, og derfor forstå risikoen ved bruk.
Kunnskap om det grunnleggende kreves for å forstå artikkelen. C språk og litt erfaring med utvikling av nettverksapplikasjoner.
All kode er skrevet på C-språk strengt i henhold til (forsiktig: lang PDF) til C11 standard for Linux og tilgjengelig på GitHub.
Hvorfor gjøre det?
Med Internetts økende popularitet begynte webservere å måtte håndtere et stort antall tilkoblinger samtidig, og derfor ble to tilnærminger prøvd: blokkering av I/O på et stort antall OS-tråder og ikke-blokkerende I/O i kombinasjon med et hendelsesvarslingssystem, også kalt "systemvelger" (epoll/kqueue/IOCP/etc).
Den første tilnærmingen innebar å opprette en ny OS-tråd for hver innkommende tilkobling. Ulempen er dårlig skalerbarhet: operativsystemet må implementere mange kontekstoverganger и systemanrop. De er dyre operasjoner og kan føre til mangel på ledig RAM med et imponerende antall tilkoblinger.
Den modifiserte versjonen fremhever fast antall tråder (trådpool), og forhindrer dermed systemet i å krasje, men introduserer samtidig et nytt problem: hvis en trådpool for øyeblikket er blokkert av lange leseoperasjoner, vil ikke andre sockets som allerede er i stand til å motta data kunne gjøre det så.
Den andre tilnærmingen bruker hendelsesvarslingssystem (systemvelger) levert av operativsystemet. Denne artikkelen diskuterer den vanligste typen systemvelger, basert på varsler (hendelser, varsler) om beredskap for I/O-operasjoner, snarere enn på varsler om fullføringen. Et forenklet eksempel på bruken kan representeres av følgende blokkdiagram:
Forskjellen mellom disse tilnærmingene er som følger:
Blokkering av I/O-operasjoner utsette brukerflyt førtil OS er riktig defragmenterer innkommende IP-pakker å byte strøm (TCP, mottar data) eller det vil ikke være nok plass tilgjengelig i de interne skrivebufferne for påfølgende sending via NIC (sende data).
Systemvelger over tid varsler programmet om at operativsystemet allerede defragmenterte IP-pakker (TCP, datamottak) eller nok plass i interne skrivebuffere allerede tilgjengelig (sende data).
For å oppsummere, å reservere en OS-tråd for hver I/O er sløsing med datakraft, fordi trådene i virkeligheten ikke gjør nyttig arbeid (det er her begrepet kommer fra "programvareavbrudd"). Systemvelgeren løser dette problemet, slik at brukerprogrammet kan bruke CPU-ressurser mye mer økonomisk.
I/O-reaktormodell
I/O-reaktoren fungerer som et lag mellom systemvelgeren og brukerkoden. Prinsippet for driften er beskrevet av følgende blokkdiagram:
La meg minne deg på at en hendelse er et varsel om at en bestemt socket er i stand til å utføre en ikke-blokkerende I/O-operasjon.
En hendelsesbehandler er en funksjon som kalles av I/O-reaktoren når en hendelse mottas, som deretter utfører en ikke-blokkerende I/O-operasjon.
Det er viktig å merke seg at I/O-reaktoren per definisjon er entrådet, men det er ingenting i veien for at konseptet kan brukes i et flertrådsmiljø i forholdet 1 tråd: 1 reaktor, og resirkulerer dermed alle CPU-kjerner.
implementering
Vi vil plassere det offentlige grensesnittet i en fil reactor.h, og implementering - i reactor.c. reactor.h vil bestå av følgende kunngjøringer:
Vis erklæringer i 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);
I/O-reaktorstrukturen består av filbeskrivelse velger epoll и hasjtabellerGHashTable, som tilordner hver socket til CallbackData (struktur av en hendelsesbehandler og et brukerargument for det).
Vær oppmerksom på at vi har aktivert muligheten til å håndtere ufullstendig type ifølge indeksen. I reactor.h vi erklærer strukturen reactorog i reactor.c vi definerer det, og forhindrer dermed brukeren i å eksplisitt endre feltene. Dette er et av mønstrene skjule data, som kort og godt passer inn i C-semantikk.
funksjoner reactor_register, reactor_deregister и reactor_reregister oppdater listen over sockets av interesse og tilsvarende hendelsesbehandlere i systemvelgeren og hashtabellen.
Etter at I/O-reaktoren har fanget opp hendelsen med beskrivelsen fd, kaller den den tilsvarende hendelsesbehandleren, som den går videre til fd, bit maske genererte hendelser og en brukerpeker til void.
Vis funksjonen 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;
}
For å oppsummere vil kjeden av funksjonsanrop i brukerkode ha følgende form:
Enkeltråds server
For å teste I/O-reaktoren under høy belastning, vil vi skrive en enkel HTTP-webserver som svarer på enhver forespørsel med et bilde.
En rask referanse til HTTP-protokollen
HTTP - Dette er protokollen applikasjonsnivå, primært brukt for server-nettleserinteraksjon.
HTTP kan enkelt brukes over transportere protokoll TCP, sende og motta meldinger i et spesifisert format spesifikasjon.
CRLF er en sekvens av to tegn: r и n, som skiller den første linjen i forespørselen, overskrifter og data.
<КОМАНДА> - en av CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. Nettleseren sender en kommando til serveren vår GET, som betyr "Send meg innholdet i filen."
<URI> - enhetlig ressursidentifikator. For eksempel hvis URI = /index.html, så ber klienten om hovedsiden til nettstedet.
<ВЕРСИЯ HTTP> — versjon av HTTP-protokollen i formatet HTTP/X.Y. Den mest brukte versjonen i dag er HTTP/1.1.
<ЗАГОЛОВОК N> er et nøkkelverdi-par i formatet <КЛЮЧ>: <ЗНАЧЕНИЕ>, sendt til serveren for videre analyse.
<ДАННЫЕ> — data som kreves av serveren for å utføre operasjonen. Ofte er det enkelt JSON eller et annet format.
<КОД СТАТУСА> er et tall som representerer resultatet av operasjonen. Serveren vår vil alltid returnere status 200 (vellykket operasjon).
<ОПИСАНИЕ СТАТУСА> — strengrepresentasjon av statuskoden. For statuskode 200 er dette OK.
<ЗАГОЛОВОК N> — overskrift i samme format som i forespørselen. Vi vil returnere titlene Content-Length (filstørrelse) og Content-Type: text/html (returdatatype).
<ДАННЫЕ> — data etterspurt av brukeren. I vårt tilfelle er dette veien til bildet inn HTML.
fil http_server.c (single threaded server) inkluderer fil common.h, som inneholder følgende funksjonsprototyper:
Vis funksjonsprototyper i fellesskap.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);
Den funksjonelle makroen er også beskrevet SAFE_CALL() og funksjonen er definert fail(). Makroen sammenligner verdien av uttrykket med feilen, og kaller funksjonen hvis betingelsen er sann fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
Funksjon fail() skriver ut de beståtte argumentene til terminalen (som printf()) og avslutter programmet med koden EXIT_FAILURE:
Funksjon new_server() returnerer filbeskrivelsen til "server"-socket opprettet av systemanrop socket(), bind() и listen() og i stand til å akseptere innkommende tilkoblinger i en ikke-blokkerende modus.
Merk at kontakten i utgangspunktet opprettes i ikke-blokkerende modus ved å bruke flagget SOCK_NONBLOCKslik at i funksjonen on_accept() (les mer) systemanrop accept() stoppet ikke trådkjøringen.
Hvis reuse_port er lik true, så vil denne funksjonen konfigurere kontakten med alternativet SO_REUSEPORT gjennom setsockopt()for å bruke den samme porten i et flertrådsmiljø (se avsnittet "Multi-tråds server").
Hendelsesbehandler on_accept() kalles etter at OS genererer en hendelse EPOLLIN, i dette tilfellet betyr at den nye forbindelsen kan aksepteres. on_accept() godtar en ny tilkobling, bytter den til ikke-blokkerende modus og registrerer seg hos en hendelsesbehandler on_recv() i en I/O-reaktor.
Hendelsesbehandler on_recv() kalles etter at OS genererer en hendelse EPOLLIN, i dette tilfellet betyr at forbindelsen registrert on_accept(), klar til å motta data.
on_recv() leser data fra tilkoblingen til HTTP-forespørselen er fullstendig mottatt, deretter registrerer den en behandler on_send() for å sende et HTTP-svar. Hvis klienten bryter forbindelsen, avregistreres stikkontakten og lukkes vha close().
Vis funksjon 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);
}
}
Hendelsesbehandler on_send() kalles etter at OS genererer en hendelse EPOLLOUT, som betyr at forbindelsen er registrert on_recv(), klar til å sende data. Denne funksjonen sender et HTTP-svar som inneholder HTML med et bilde til klienten og endrer deretter hendelsesbehandleren tilbake til on_recv().
Og til slutt, i filen http_server.c, i funksjon main() vi lager en I/O-reaktor ved hjelp av reactor_new(), lag en serversocket og registrer den, start reaktoren med reactor_run() i nøyaktig ett minutt, og så slipper vi ressurser og avslutter programmet.
La oss sjekke at alt fungerer som forventet. Kompilere (chmod a+x compile.sh && ./compile.sh i prosjektroten) og start den selvskrevne serveren, åpne http://127.0.0.1:18470 i nettleseren og se hva vi forventet:
La oss måle ytelsen til en enkelt-tråds server. La oss åpne to terminaler: i den ene kjører vi ./http_server, i en annen - wrk. Etter et minutt vil følgende statistikk vises i den andre terminalen:
Vår entrådede server var i stand til å behandle over 11 millioner forespørsler per minutt fra 100 tilkoblinger. Ikke et dårlig resultat, men kan det forbedres?
Multithreaded server
Som nevnt ovenfor kan I/O-reaktoren lages i separate tråder, og dermed utnytte alle CPU-kjerner. La oss sette denne tilnærmingen ut i livet:
Vær oppmerksom på at funksjonsargumentet new_server() talsmenn true. Dette betyr at vi tilordner alternativet til serverkontakten SO_REUSEPORTå bruke den i et flertrådsmiljø. Du kan lese flere detaljer her.
Antallet forespørsler behandlet på 1 minutt økte med ~3.28 ganger! Men vi manglet bare ~XNUMX millioner av det runde tallet, så la oss prøve å fikse det.
La oss først se på statistikken som genereres perf:
Bruker CPU Affinity, sammenstilling med -march=native, PGO, en økning i antall treff cache, øke MAX_EVENTS og bruk EPOLLET ga ikke nevneverdig økning i ytelsen. Men hva skjer hvis du øker antall samtidige forbindelser?
Det ønskede resultatet ble oppnådd, og med det en interessant graf som viser avhengigheten av antall behandlede forespørsler på 1 minutt på antall tilkoblinger:
Vi ser at etter et par hundre tilkoblinger synker antallet behandlede forespørsler for begge serverne kraftig (i flertrådsversjonen er dette mer merkbart). Er dette relatert til Linux TCP/IP-stackimplementeringen? Skriv gjerne dine antakelser om denne oppførselen til grafen og optimaliseringer for flertrådede og enkeltrådede alternativer i kommentarfeltet.
Som bemerket i kommentarfeltet viser ikke denne ytelsestesten oppførselen til I/O-reaktoren under reelle belastninger, fordi serveren nesten alltid samhandler med databasen, sender ut logger, bruker kryptografi med TLS etc., som et resultat av at belastningen blir ujevn (dynamisk). Tester sammen med tredjepartskomponenter vil bli utført i artikkelen om I/O-proaktoren.
Ulemper med I/O-reaktor
Du må forstå at I/O-reaktoren ikke er uten ulemper, nemlig:
Å bruke en I/O-reaktor i et flertrådsmiljø er noe vanskeligere, fordi du må administrere flytene manuelt.
Praksis viser at i de fleste tilfeller er belastningen ujevn, noe som kan føre til at en tråd logger mens en annen er opptatt med arbeid.
Hvis en hendelsesbehandler blokkerer en tråd, vil selve systemvelgeren også blokkere, noe som kan føre til vanskelige feil.
Løser disse problemene I/O proaktor, som ofte har en planlegger som jevnt fordeler belastningen til en pool av tråder, og har også en mer praktisk API. Vi vil snakke om det senere, i min andre artikkel.
Konklusjon
Det er her reisen vår fra teori rett inn i profileringseksosen har kommet til en slutt.
Du bør ikke dvele ved dette, fordi det er mange andre like interessante tilnærminger til å skrive nettverksprogramvare med forskjellige nivåer av bekvemmelighet og hastighet. Interessante, etter min mening, er lenker gitt nedenfor.