Neste artigo, analizaremos os pormenores e os contras dun reactor de E/S e como funciona, escribiremos unha implementación en menos de 200 liñas de código e realizaremos un proceso sinxelo de servidor HTTP con máis de 40 millóns de solicitudes/min.
Prefacio
O artigo foi escrito para axudar a comprender o funcionamento do reactor de E/S e, polo tanto, comprender os riscos ao usalo.
Requírese coñecementos básicos para comprender o artigo. Linguaxe C e algunha experiencia no desenvolvemento de aplicacións de rede.
Todo o código está escrito en linguaxe C estrictamente segundo (Atención: PDF longo) ao estándar C11 para Linux e dispoñible en GitHub.
Por que facelo?
Coa crecente popularidade de Internet, os servidores web comezaron a necesitar manexar un gran número de conexións simultáneamente e, polo tanto, probáronse dous enfoques: bloquear E/S nun gran número de fíos do sistema operativo e E/S sen bloqueo en combinación con un sistema de notificación de eventos, tamén chamado "selector de sistema" (epollo/kqueue/IOCP/etc).
O primeiro enfoque consistiu na creación dun novo fío de sistema operativo para cada conexión entrante. A súa desvantaxe é a escasa escalabilidade: o sistema operativo terá que implementar moitos transicións de contexto и chamadas ao sistema. Son operacións caras e poden levar á falta de memoria RAM libre cun número impresionante de conexións.
Destaca a versión modificada número fixo de fíos (grupo de fíos), evitando así o fallo do sistema, pero ao mesmo tempo introducindo un novo problema: se un grupo de fíos está actualmente bloqueado por operacións de lectura longas, entón outros sockets que xa poden recibir datos non poderán facelo. así.
O segundo enfoque utiliza sistema de notificación de eventos (selector do sistema) proporcionado polo SO. Este artigo analiza o tipo máis común de selector de sistema, baseado en alertas (eventos, notificacións) sobre a preparación para operacións de E/S, en lugar de notificacións sobre a súa finalización. Un exemplo simplificado do seu uso pódese representar no seguinte diagrama de bloques:
A diferenza entre estes enfoques é a seguinte:
Bloqueo de operacións de E/S suspender fluxo de usuarios ata queata que o sistema operativo estea correctamente desfragmenta entrante paquetes IP ao fluxo de bytes (TCP, recibindo datos) ou non haberá suficiente espazo dispoñible nos búfers de escritura internos para o envío posterior a través de NIC (enviando datos).
Selector do sistema co paso do tempo notifica ao programa que o SO xa paquetes IP desfragmentados (TCP, recepción de datos) ou espazo suficiente nos búferes de escritura internos xa dispoñible (envío de datos).
En resumo, reservar un fío de SO para cada E/S é un desperdicio de potencia informática, porque en realidade, os fíos non están a facer un traballo útil (de aquí vén o termo "interrupción de software"). O selector do sistema resolve este problema, permitindo que o programa de usuario utilice os recursos da CPU dun xeito moito máis económico.
Modelo de reactor de E/S
O reactor de E/S actúa como unha capa entre o selector do sistema e o código de usuario. O principio do seu funcionamento descríbese no seguinte diagrama de bloques:
Permíteme recordarche que un evento é unha notificación de que un determinado socket é capaz de realizar unha operación de E/S sen bloqueo.
Un controlador de eventos é unha función chamada polo reactor de E/S cando se recibe un evento, que despois realiza unha operación de E/S sen bloqueo.
É importante ter en conta que o reactor de E/S é, por definición, dun só subproceso, pero non hai nada que impida que o concepto se use nun ambiente multiproceso nunha proporción de 1 fío: 1 reactor, polo que se reciclan todos os núcleos da CPU.
Implantación
Colocaremos a interface pública nun ficheiro reactor.h, e implementación - en reactor.c. reactor.h constará dos seguintes anuncios:
Mostrar declaracións no 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);
A estrutura do reactor de E/S consta de descriptor de ficheiro selector epollo и táboas hashGHashTable, que asigna cada socket CallbackData (estrutura dun controlador de eventos e un argumento de usuario para el).
Teña en conta que activamos a capacidade de manexar tipo incompleto segundo o índice. EN reactor.h declaramos a estrutura reactor, e dentro reactor.c definímolo, evitando así que o usuario modifique explícitamente os seus campos. Este é un dos patróns ocultar datos, que encaixa de forma sucinta na semántica C.
Funcións reactor_register, reactor_deregister и reactor_reregister actualizar a lista de sockets de interese e os controladores de eventos correspondentes no selector do sistema e na táboa hash.
Despois de que o reactor de E/S interceptase o evento co descritor fd, chama ao controlador de eventos correspondente, ao que pasa fd, máscara de bits eventos xerados e un punteiro de usuario a void.
Mostra a función 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 resumo, a cadea de chamadas de función no código de usuario terá a seguinte forma:
Servidor de fíos únicos
Para probar o reactor de E/S baixo alta carga, escribiremos un servidor web HTTP sinxelo que responda a calquera solicitude cunha imaxe.
Unha referencia rápida ao protocolo HTTP
HTTP - Este é o protocolo nivel de aplicación, usado principalmente para a interacción servidor-navegador.
HTTP pódese usar facilmente transporte protocolo TCP, enviando e recibindo mensaxes nun formato especificado especificación.
CRLF é unha secuencia de dous caracteres: r и n, separando a primeira liña da solicitude, as cabeceiras e os datos.
<КОМАНДА> - un de CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. O navegador enviará un comando ao noso servidor GET, que significa "Envíame o contido do ficheiro".
<КОД СТАТУСА> é un número que representa o resultado da operación. O noso servidor sempre devolverá o estado 200 (operación exitosa).
<ОПИСАНИЕ СТАТУСА> — representación en cadea do código de estado. Para o código de estado 200, isto é OK.
<ЗАГОЛОВОК N> — cabeceira co mesmo formato que na solicitude. Devolveremos os títulos Content-Length (tamaño do ficheiro) e Content-Type: text/html (tipo de datos de retorno).
<ДАННЫЕ> - datos solicitados polo usuario. No noso caso, este é o camiño cara á imaxe HTML.
arquivo http_server.c (servidor de fío único) inclúe ficheiro common.h, que contén os seguintes prototipos de funcións:
Mostrar prototipos de funcións en común.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);
Tamén se describe a macro funcional SAFE_CALL() e defínese a función fail(). A macro compara o valor da expresión co erro e, se a condición é certa, chama á función fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
Función fail() imprime os argumentos pasados ao terminal (como printf()) e finaliza o programa co código EXIT_FAILURE:
Función new_server() devolve o descritor do ficheiro do socket "servidor" creado polas chamadas do sistema socket(), bind() и listen() e capaz de aceptar conexións entrantes nun modo sen bloqueo.
Teña en conta que o socket créase inicialmente en modo sen bloqueo usando a bandeira SOCK_NONBLOCKpara que na función on_accept() (leer máis) chamada do sistema accept() non detivo a execución do fío.
Se reuse_port é igual a true, entón esta función configurará o socket coa opción SO_REUSEPORT a través setsockopt()para usar o mesmo porto nun ambiente multiproceso (consulte a sección "Servidor multi-fíos").
Manexador de eventos on_accept() chamado despois de que o sistema operativo xere un evento EPOLLIN, significando neste caso que se pode aceptar a nova conexión. on_accept() acepta unha nova conexión, cámbiaa ao modo sen bloqueo e rexístrase cun controlador de eventos on_recv() nun reactor de E/S.
Manexador de eventos on_recv() chamado despois de que o sistema operativo xere un evento EPOLLIN, significando neste caso que a conexión rexistrada on_accept(), listo para recibir datos.
on_recv() le os datos da conexión ata que se recibe completamente a solicitude HTTP e, a continuación, rexistra un controlador on_send() para enviar unha resposta HTTP. Se o cliente rompe a conexión, o socket é cancelado e péchase usando close().
Mostrar función 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);
}
}
Manexador de eventos on_send() chamado despois de que o sistema operativo xere un evento EPOLLOUT, o que significa que a conexión rexistrada on_recv(), listo para enviar datos. Esta función envía unha resposta HTTP que contén HTML cunha imaxe ao cliente e despois cambia de novo o controlador de eventos on_recv().
E por último, no arquivo http_server.c, en función main() creamos un reactor de E/S utilizando reactor_new(), crea un socket de servidor e rexístrao, inicia o reactor usando reactor_run() durante exactamente un minuto, e despois liberamos recursos e saímos do programa.
Comprobamos que todo funciona como se esperaba. Compilación (chmod a+x compile.sh && ./compile.sh na raíz do proxecto) e inicie o servidor autoescrito, abra http://127.0.0.1:18470 no navegador e mira o que esperabamos:
Midamos o rendemento dun servidor de fío único. Abrimos dous terminais: nun executaremos ./http_server, de forma diferente - traballo. Despois dun minuto, no segundo terminal mostraranse as seguintes estatísticas:
O noso servidor de fío único puido procesar máis de 11 millóns de solicitudes por minuto procedentes de 100 conexións. Non é mal resultado, pero pódese mellorar?
Servidor multiproceso
Como se mencionou anteriormente, o reactor de E/S pódese crear en fíos separados, utilizando así todos os núcleos da CPU. Poñamos en práctica este enfoque:
Teña en conta que o argumento da función new_server() defensores true. Isto significa que asignamos a opción ao socket do servidor SO_REUSEPORTpara usalo nun ambiente multiproceso. Podes ler máis detalles aquí.
Segunda tirada
Agora imos medir o rendemento dun servidor multiproceso:
O número de solicitudes procesadas en 1 minuto aumentou en ~3.28 veces. Pero só nos quedamos ~XNUMX millóns para alcanzar o número redondo, así que imos tentar solucionalo.
Primeiro vexamos as estatísticas xeradas perfecto:
Usando CPU Affinity, compilación con -march=native, PGO, un aumento no número de visitas caché, Aumentar MAX_EVENTS e uso EPOLLET non deu un aumento significativo no rendemento. Pero que pasa se aumentas o número de conexións simultáneas?
Obtívose o resultado desexado, e con el un gráfico interesante que mostra a dependencia do número de solicitudes procesadas en 1 minuto co número de conexións:
Vemos que despois dun par de centos de conexións, o número de solicitudes procesadas para ambos os servidores cae drasticamente (na versión multifío isto nótase máis). ¿Está relacionado coa implementación da pila TCP/IP de Linux? Non dubides en escribir nos comentarios as túas suposicións sobre este comportamento do gráfico e as optimizacións para opcións de fíos múltiples e de fío único.
Como anotado nos comentarios, esta proba de rendemento non mostra o comportamento do reactor de E/S baixo cargas reais, porque case sempre o servidor interactúa coa base de datos, emite rexistros, usa criptografía con TLS etc., como resultado do cal a carga se fai non uniforme (dinámica). As probas xunto con compoñentes de terceiros realizaranse no artigo sobre o proactor de E/S.
Desvantaxes do reactor de E/S
Debe entender que o reactor de E/S non está exento de inconvenientes, a saber:
Usar un reactor de E/S nun ambiente multiproceso é algo máis difícil, porque terás que xestionar manualmente os fluxos.
A práctica mostra que na maioría dos casos a carga non é uniforme, o que pode levar a que un fío rexistre mentres outro está ocupado co traballo.
Se un controlador de eventos bloquea un fío, entón o propio selector do sistema tamén se bloqueará, o que pode provocar erros difíciles de atopar.
Resolve estes problemas Proactor de E/S, que a miúdo ten un programador que distribúe uniformemente a carga a un conxunto de fíos e tamén ten unha API máis conveniente. Diso falaremos máis adiante, no meu outro artigo.
Conclusión
Aquí é onde a nosa viaxe desde a teoría ata o escape do perfilador chegou ao seu fin.
Non debería deterse nisto, porque hai moitos outros enfoques igualmente interesantes para escribir software de rede con diferentes niveis de comodidade e velocidade. Interesantes, na miña opinión, as ligazóns están a continuación.