En aquest article, veurem els detalls d'un reactor d'E/S i com funciona, escriurem una implementació en menys de 200 línies de codi i realitzarem un procés senzill de servidor HTTP de més de 40 milions de sol·licituds/min.
Prefaci
L'article va ser escrit per ajudar a entendre el funcionament del reactor d'E/S i, per tant, entendre els riscos en utilitzar-lo.
Es requereix coneixements bàsics per entendre l'article. llenguatge C i una mica d'experiència en el desenvolupament d'aplicacions de xarxa.
Tot el codi està escrit en llenguatge C estrictament segons (precaució: PDF llarg) a l'estàndard C11 per a Linux i disponible a GitHub.
Per què fer-ho?
Amb la creixent popularitat d'Internet, els servidors web van començar a necessitar gestionar un gran nombre de connexions simultàniament i, per tant, es van provar dos enfocaments: bloquejar E/S en un gran nombre de fils del sistema operatiu i E/S no bloquejant en combinació amb un sistema de notificació d'esdeveniments, també anomenat "selector del sistema" (epoll/kqueue/IOCP/etc).
El primer enfocament consistia a crear un nou fil del sistema operatiu per a cada connexió entrant. El seu inconvenient és la poca escalabilitat: el sistema operatiu n'haurà d'implementar molts transicions de context и trucades al sistema. Són operacions cares i poden provocar una manca de memòria RAM lliure amb un nombre impressionant de connexions.
La versió modificada destaca nombre fix de fils (agrupació de fils), evitant així que el sistema avorti l'execució, però al mateix temps introdueix un nou problema: si actualment un grup de fils està bloquejat per operacions de lectura llarga, llavors altres sòcols que ja poden rebre dades no podran Fes-ho.
El segon enfocament utilitza sistema de notificació d'esdeveniments (selector del sistema) proporcionat pel sistema operatiu. Aquest article tracta el tipus més comú de selector del sistema, basat en alertes (esdeveniments, notificacions) sobre la preparació per a operacions d'E/S, en lloc de notificacions sobre la seva finalització. Un exemple simplificat del seu ús es pot representar amb el següent diagrama de blocs:
La diferència entre aquests enfocaments és la següent:
Bloqueig d'operacions d'E/S suspendre flux d'usuari fins quefins que el sistema operatiu estigui correctament desfragmenta entrant paquets IP al flux de bytes (TCP, rebent dades) o no hi haurà prou espai disponible als buffers d'escriptura interns per a l'enviament posterior mitjançant NIC (enviament de dades).
Selector del sistema al llarg del temps notifica al programa que el sistema operatiu ja paquets IP desfragmentats (TCP, recepció de dades) o espai suficient als buffers d'escriptura interns ja disponible (enviament de dades).
En resum, reservar un fil del sistema operatiu per a cada E/S és un malbaratament de potència de càlcul, perquè en realitat, els fils no fan feina útil (d'aquí el terme "interrupció de programari"). El selector del sistema resol aquest problema, permetent al programa d'usuari utilitzar els recursos de la CPU de manera molt més econòmica.
Model de reactor d'E/S
El reactor d'E/S actua com una capa entre el selector del sistema i el codi d'usuari. El principi del seu funcionament es descriu al següent diagrama de blocs:
Permeteu-me que us recordi que un esdeveniment és una notificació que un determinat sòcol és capaç de realitzar una operació d'E/S sense bloqueig.
Un controlador d'esdeveniments és una funció cridada pel reactor d'E/S quan es rep un esdeveniment, que després realitza una operació d'E/S sense bloqueig.
És important tenir en compte que el reactor d'E/S és, per definició, d'un sol fil, però no hi ha res que impedeixi que el concepte s'utilitzi en un entorn de múltiples fils amb una proporció d'1 fil: 1 reactor, reciclant així tots els nuclis de la CPU.
Implementació
Col·locarem la interfície pública en un fitxer reactor.h, i implementació - en reactor.c. reactor.h constarà dels següents anuncis:
Mostra declaracions al 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);
L'estructura del reactor d'E/S consta de descriptor de fitxer selector epoll и taules hashGHashTable, que assigna cada sòcol a CallbackData (estructura d'un controlador d'esdeveniments i un argument d'usuari per a aquest).
Tingueu en compte que hem habilitat la capacitat de gestionar tipus incomplet segons l'índex. EN reactor.h declarem l'estructura reactori en reactor.c el definim, evitant així que l'usuari canviï explícitament els seus camps. Aquest és un dels patrons ocultar dades, que encaixa succintament en la semàntica C.
Funcions reactor_register, reactor_deregister и reactor_reregister actualitzeu la llista de sockets d'interès i els controladors d'esdeveniments corresponents al selector del sistema i a la taula hash.
Després que el reactor d'E/S hagi interceptat l'esdeveniment amb el descriptor fd, crida al controlador d'esdeveniments corresponent, al qual passa fd, màscara de bits esdeveniments generats i un punter d'usuari a void.
Mostra la funció 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;
}
En resum, la cadena de trucades de funcions al codi d'usuari tindrà la forma següent:
Servidor d'un sol fil
Per tal de provar el reactor d'E/S amb una càrrega elevada, escriurem un servidor web HTTP senzill que respongui a qualsevol sol·licitud amb una imatge.
Una referència ràpida al protocol HTTP
HTTP - Aquest és el protocol nivell d'aplicació, utilitzat principalment per a la interacció servidor-navegador.
HTTP es pot utilitzar fàcilment transport protocol TCP, enviant i rebent missatges en un format especificat especificació.
CRLF és una seqüència de dos caràcters: r и n, separant la primera línia de la sol·licitud, les capçaleres i les dades.
<КОМАНДА> - una de CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. El navegador enviarà una ordre al nostre servidor GET, que significa "Envia'm el contingut del fitxer".
<URI> - identificador de recurs uniforme. Per exemple, si URI = /index.html, aleshores el client sol·licita la pàgina principal del lloc.
<ВЕРСИЯ HTTP> — versió del protocol HTTP en el format HTTP/X.Y. La versió més utilitzada avui és HTTP/1.1.
<ЗАГОЛОВОК N> és una parella clau-valor en el format <КЛЮЧ>: <ЗНАЧЕНИЕ>, enviat al servidor per a una anàlisi posterior.
<ДАННЫЕ> — dades requerides pel servidor per realitzar l'operació. Sovint és senzill JSON o qualsevol altre format.
<КОД СТАТУСА> és un nombre que representa el resultat de l'operació. El nostre servidor sempre retornarà l'estat 200 (operació correcta).
<ОПИСАНИЕ СТАТУСА> — representació en cadena del codi d'estat. Per al codi d'estat 200, això és OK.
<ЗАГОЛОВОК N> — capçalera del mateix format que a la sol·licitud. Tornarem els títols Content-Length (mida del fitxer) i Content-Type: text/html (tipus de dades de retorn).
<ДАННЫЕ> — dades sol·licitades per l'usuari. En el nostre cas, aquest és el camí cap a la imatge HTML.
expedient http_server.c (servidor d'un sol fil) inclou fitxer common.h, que conté els prototips de funcions següents:
Mostra prototips de funcions en comú.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);
També es descriu la macro funcional SAFE_CALL() i es defineix la funció fail(). La macro compara el valor de l'expressió amb l'error i, si la condició és certa, crida a la funció fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
Funció fail() imprimeix els arguments passats al terminal (com ara printf()) i finalitza el programa amb el codi EXIT_FAILURE:
Funció new_server() retorna el descriptor de fitxer del sòcol "servidor" creat per les trucades del sistema socket(), bind() и listen() i capaç d'acceptar connexions entrants en un mode sense bloqueig.
Tingueu en compte que el sòcol es crea inicialment en mode sense bloqueig mitjançant la bandera SOCK_NONBLOCKde manera que en la funció on_accept() (llegir més) trucada al sistema accept() no va aturar l'execució del fil.
Si reuse_port és igual a true, llavors aquesta funció configurarà el sòcol amb l'opció SO_REUSEPORT a través setsockopt()per utilitzar el mateix port en un entorn multifil (vegeu la secció "Servidor multifil").
Gestor d'esdeveniments on_accept() cridat després que el sistema operatiu generi un esdeveniment EPOLLIN, en aquest cas significa que es pot acceptar la nova connexió. on_accept() accepta una connexió nova, la canvia al mode sense bloqueig i es registra amb un gestor d'esdeveniments on_recv() en un reactor d'E/S.
Gestor d'esdeveniments on_recv() cridat després que el sistema operatiu generi un esdeveniment EPOLLIN, en aquest cas significa que la connexió registrada on_accept(), llest per rebre dades.
on_recv() llegeix dades de la connexió fins que la sol·licitud HTTP es rep completament i després registra un controlador on_send() per enviar una resposta HTTP. Si el client trenca la connexió, el sòcol es dona de baixa i es tanca utilitzant close().
Mostra la funció 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);
}
}
Gestor d'esdeveniments on_send() cridat després que el sistema operatiu generi un esdeveniment EPOLLOUT, el que significa que la connexió registrada on_recv(), llest per enviar dades. Aquesta funció envia una resposta HTTP que conté HTML amb una imatge al client i després torna a canviar el controlador d'esdeveniments on_recv().
I finalment, a l'arxiu http_server.c, en funció main() creem un reactor d'E/S utilitzant reactor_new(), creeu un sòcol de servidor i registreu-lo, engegueu el reactor fent servir reactor_run() durant exactament un minut, i després alliberem recursos i sortim del programa.
Comprovem que tot funciona com s'esperava. Compilant (chmod a+x compile.sh && ./compile.sh a l'arrel del projecte) i inicieu el servidor escrit per si mateix, obre http://127.0.0.1:18470 al navegador i mireu què esperàvem:
Mesurem el rendiment d'un servidor d'un sol fil. Obrim dos terminals: en un anirem executant ./http_server, d'una manera diferent - treball. Al cap d'un minut, es mostraran les estadístiques següents al segon terminal:
El nostre servidor d'un sol fil va poder processar més d'11 milions de sol·licituds per minut procedents de 100 connexions. No és un mal resultat, però es pot millorar?
Servidor multifils
Com s'ha esmentat anteriorment, el reactor d'E/S es pot crear en fils separats, utilitzant així tots els nuclis de la CPU. Posem en pràctica aquest enfocament:
Tingueu en compte que l'argument de la funció new_server() favors true. Això vol dir que assignem l'opció al sòcol del servidor SO_REUSEPORTper utilitzar-lo en un entorn multifils. Podeu llegir més detalls aquí.
Segona tirada
Ara mesurem el rendiment d'un servidor multiprocés:
El nombre de sol·licituds processades en 1 minut ha augmentat ~3.28 vegades! Però només ens quedaven uns XNUMX milions per arribar al número rodó, així que intentem arreglar-ho.
Primer mirem les estadístiques generades perfecte:
Ús de CPU Affinity, recopilació amb -march=native, PGO, un augment del nombre de visites memòria cau, augmentar MAX_EVENTS i ús EPOLLET no ha donat un augment significatiu del rendiment. Però què passa si augmenteu el nombre de connexions simultànies?
Es va obtenir el resultat desitjat, i amb ell un interessant gràfic que mostra la dependència del nombre de peticions processades en 1 minut del nombre de connexions:
Veiem que després d'un parell de centenars de connexions, el nombre de sol·licituds processades per als dos servidors disminueix bruscament (a la versió multifils això es nota més). Està relacionat amb la implementació de la pila TCP/IP de Linux? No dubteu a escriure els vostres supòsits sobre aquest comportament del gràfic i les optimitzacions per a opcions de fils múltiples i d'un sol fil als comentaris.
Com assenyalat als comentaris, aquesta prova de rendiment no mostra el comportament del reactor d'E/S sota càrregues reals, perquè gairebé sempre el servidor interactua amb la base de dades, genera registres, utilitza criptografia amb TLS etc., com a conseqüència de la qual cosa la càrrega esdevé no uniforme (dinàmica). Les proves juntament amb components de tercers es realitzaran a l'article sobre el proactor d'E/S.
Inconvenients del reactor d'E/S
Heu d'entendre que el reactor d'E/S no té els seus inconvenients, a saber:
L'ús d'un reactor d'E/S en un entorn multifil és una mica més difícil, perquè hauràs de gestionar manualment els fluxos.
La pràctica demostra que, en la majoria dels casos, la càrrega no és uniforme, cosa que pot provocar que un fil registri mentre un altre estigui ocupat amb la feina.
Si un gestor d'esdeveniments bloqueja un fil, el selector del sistema també es bloquejarà, cosa que pot provocar errors difícils de trobar.
Soluciona aquests problemes Proactor d'E/S, que sovint té un programador que distribueix uniformement la càrrega a un conjunt de fils i també té una API més convenient. En parlarem més endavant, en el meu altre article.
Conclusió
Aquí és on s'ha acabat el nostre viatge des de la teoria fins a l'escapament del perfilador.
No us hauríeu de detenir en això, perquè hi ha molts altres enfocaments igualment interessants per escriure programari de xarxa amb diferents nivells de comoditat i velocitat. Interessant, al meu entendre, es donen enllaços a continuació.