En este artículo, veremos los entresijos de un reactor de E/S y cómo funciona, escribiremos una implementación en menos de 200 líneas de código y crearemos un servidor HTTP simple que procese más de 40 millones de solicitudes/min.
prefacio
El artículo fue escrito para ayudar a comprender el funcionamiento del reactor de E/S y, por lo tanto, comprender los riesgos al usarlo.
Se requieren conocimientos de los conceptos básicos para comprender el artículo. lenguaje c y cierta experiencia en el desarrollo de aplicaciones de red.
Todo el código está escrito en lenguaje C estrictamente de acuerdo con (precaución: PDF largo) según estándar C11 para Linux y disponible en GitHub.
¿Por qué lo hace?
Con la creciente popularidad de Internet, los servidores web comenzaron a necesitar manejar una gran cantidad de conexiones simultáneamente y, por lo tanto, se probaron dos enfoques: bloquear E/S en una gran cantidad de subprocesos del sistema operativo y E/S sin bloqueo en combinación con un sistema de notificación de eventos, también llamado "selector de sistema" (encuesta/cola/IOCP/ etc).
El primer enfoque implicó la creación de un nuevo subproceso del sistema operativo para cada conexión entrante. Su desventaja es la escasa escalabilidad: el sistema operativo tendrá que implementar muchos transiciones de contexto и llamadas al sistema. Son operaciones costosas y pueden provocar falta de RAM libre con un número impresionante de conexiones.
La versión modificada destaca número fijo de hilos (grupo de subprocesos), evitando así que el sistema falle, pero al mismo tiempo introduce un nuevo problema: si un grupo de subprocesos está actualmente bloqueado por operaciones de lectura largas, otros sockets que ya pueden recibir datos no podrán hacerlo. entonces.
El segundo enfoque utiliza sistema de notificación de eventos (selector de sistema) proporcionado por el sistema operativo. Este artículo analiza el tipo más común de selector de sistema, basado en alertas (eventos, notificaciones) sobre la preparación para operaciones de E/S, en lugar de en notificaciones sobre su finalización. Un ejemplo simplificado de su uso se puede representar mediante el siguiente diagrama de bloques:
La diferencia entre estos enfoques es la siguiente:
Bloqueo de operaciones de E/S suspender flujo de usuarios hasta entonceshasta que el sistema operativo esté correctamente desfragmenta entrante paquetes IP a flujo de bytes (TCP, recibir datos) o no habrá suficiente espacio disponible en los buffers de escritura internos para el envío posterior a través de NIC (enviando datos).
Selector de sistema despues de un rato notifica al programa que el sistema operativo ya paquetes IP desfragmentados (TCP, recepción de datos) o suficiente espacio en los buffers de escritura internos ya disponible (enviando datos).
En resumen, reservar un subproceso del sistema operativo para cada E/S es un desperdicio de potencia informática, porque en realidad los subprocesos no realizan un trabajo útil (de aquí proviene el término). "interrupción de software"). El selector de sistema resuelve este problema, permitiendo que el programa de usuario utilice los recursos de la CPU de forma mucho más económica.
Modelo de reactor de E/S
El reactor de E/S actúa como una capa entre el selector del sistema y el código de usuario. El principio de su funcionamiento se describe en el siguiente diagrama de bloques:
Permítame recordarle que un evento es una notificación de que un determinado socket puede realizar una operación de E/S sin bloqueo.
Un controlador de eventos es una función llamada por el reactor de E/S cuando se recibe un evento, que luego realiza una operación de E/S sin bloqueo.
Es importante tener en cuenta que el reactor de E/S es, por definición, de un solo subproceso, pero no hay nada que impida que el concepto se utilice en un entorno de múltiples subprocesos en una proporción de 1 subproceso: 1 reactor, reciclando así todos los núcleos de la CPU.
implementación
Colocaremos la interfaz pública en un archivo. reactor.h, e implementación - en reactor.c. reactor.h estará compuesto por los siguientes anuncios:
Mostrar declaraciones en 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);
La estructura del reactor de E/S consta de descriptor de archivo selector encuesta и tablas hashGHashTable, que asigna cada socket a CallbackData (estructura de un controlador de eventos y un argumento de usuario para ello).
Tenga en cuenta que hemos habilitado la capacidad de manejar tipo incompleto según el índice. EN reactor.h declaramos la estructura reactor, Y en reactor.c lo definimos, evitando así que el usuario cambie explícitamente sus campos. Este es uno de los patrones. ocultando datos, que encaja sucintamente en la semántica de C.
funciones reactor_register, reactor_deregister и reactor_reregister actualice la lista de sockets de interés y los controladores de eventos correspondientes en el selector del sistema y la tabla hash.
Después de que el reactor de E/S haya interceptado el evento con el descriptor fd, llama al controlador de eventos correspondiente, al que pasa fd, máscara de bits eventos generados y un puntero de usuario a void.
Mostrar 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 resumen, la cadena de llamadas a funciones en el código de usuario tomará la siguiente forma:
Servidor de un solo subproceso
Para probar el reactor de E/S bajo carga alta, escribiremos un servidor web HTTP simple que responda a cualquier solicitud con una imagen.
Una referencia rápida al protocolo HTTP.
HTTP - este es el protocolo nivel de aplicación, utilizado principalmente para la interacción servidor-navegador.
HTTP se puede utilizar fácilmente transporte protocolo TCP, enviar y recibir mensajes en un formato especificado especificación.
CRLF es una secuencia de dos caracteres: r и n, separando la primera línea de la solicitud, encabezados y datos.
<КОМАНДА> - uno de CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE. El navegador enviará un comando a nuestro servidor. GET, que significa "Envíame el contenido del archivo".
<КОД СТАТУСА> es un número que representa el resultado de la operación. Nuestro servidor siempre devolverá el estado 200 (operación exitosa).
<ОПИСАНИЕ СТАТУСА> — representación en cadena del código de estado. Para el código de estado 200 esto es OK.
<ЗАГОЛОВОК N> — encabezado del mismo formato que en la solicitud. Devolveremos los títulos Content-Length (tamaño de archivo) y Content-Type: text/html (tipo de datos de retorno).
<ДАННЫЕ> — datos solicitados por el usuario. En nuestro caso, este es el camino a la imagen en HTML.
Expediente http_server.c (servidor de un solo subproceso) incluye archivo common.h, que contiene los siguientes prototipos de funciones:
Mostrar prototipos de funciones 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);
También se describe la macro funcional. SAFE_CALL() y la función está definida fail(). La macro compara el valor de la expresión con el error y, si la condición es verdadera, llama a la función. fail():
#define SAFE_CALL(call, error)
do {
if ((call) == error) {
fail("%s", #call);
}
} while (false)
Función fail() imprime los argumentos pasados en la terminal (como printf()) y finaliza el programa con el código EXIT_FAILURE:
Función new_server() devuelve el descriptor de archivo del socket "servidor" creado por llamadas al sistema socket(), bind() и listen() y capaz de aceptar conexiones entrantes en modo sin bloqueo.
Tenga en cuenta que el socket se crea inicialmente en modo sin bloqueo usando la bandera SOCK_NONBLOCKpara que en la función on_accept() (leer más) llamada al sistema accept() no detuvo la ejecución del hilo.
si reuse_port es igual a true, entonces esta función configurará el socket con la opción SO_REUSEPORT a través de setsockopt()para utilizar el mismo puerto en un entorno multiproceso (consulte la sección “Servidor multiproceso”).
Controlador de eventos on_accept() llamado después de que el sistema operativo genera un evento EPOLLIN, en este caso significa que se puede aceptar la nueva conexión. on_accept() acepta una nueva conexión, la cambia al modo sin bloqueo y se registra con un controlador de eventos on_recv() en un reactor de E/S.
Controlador de eventos on_recv() llamado después de que el sistema operativo genera un evento EPOLLIN, en este caso significa que la conexión registrada on_accept(), listo para recibir datos.
on_recv() lee datos de la conexión hasta que la solicitud HTTP se recibe por completo, luego registra un controlador on_send() para enviar una respuesta HTTP. Si el cliente interrumpe la conexión, el socket se cancela y se cierra 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);
}
}
Controlador de eventos on_send() llamado después de que el sistema operativo genera un evento EPOLLOUT, lo que significa que la conexión registrada on_recv(), listo para enviar datos. Esta función envía una respuesta HTTP que contiene HTML con una imagen al cliente y luego cambia el controlador de eventos nuevamente a on_recv().
Y finalmente, en el archivo. http_server.c, en función main() Creamos un reactor de E/S usando reactor_new(), cree un socket de servidor y regístrelo, inicie el reactor usando reactor_run() durante exactamente un minuto, y luego liberamos recursos y salimos del programa.
Comprobemos que todo funciona como se esperaba. Compilando (chmod a+x compile.sh && ./compile.sh en la raíz del proyecto) e inicie el servidor autoescrito, abra http://127.0.0.1:18470 en el navegador y ver lo que esperábamos:
Midamos el rendimiento de un servidor de un solo subproceso. Abramos dos terminales: en una ejecutaremos ./http_server, de una manera diferente - trabajar. Después de un minuto, se mostrarán las siguientes estadísticas en la segunda terminal:
Nuestro servidor de subproceso único pudo procesar más de 11 millones de solicitudes por minuto provenientes de 100 conexiones. No es un mal resultado, pero ¿se puede mejorar?
Servidor multiproceso
Como se mencionó anteriormente, el reactor de E/S se puede crear en subprocesos separados, utilizando así todos los núcleos de la CPU. Pongamos este enfoque en práctica:
Tenga en cuenta que el argumento de la función new_server() defensores true. Esto significa que asignamos la opción al socket del servidor. SO_REUSEPORTpara usarlo en un entorno multiproceso. Puedes leer más detalles aquí.
Segunda carrera
Ahora midamos el rendimiento de un servidor multiproceso:
¡La cantidad de solicitudes procesadas en 1 minuto aumentó ~3.28 veces! Pero solo nos faltaban ~XNUMX millones para alcanzar el número redondo, así que intentemos solucionarlo.
Usando afinidad de CPU, compilación con -march=native, PGO, un aumento en el número de visitas tarde, aumentar MAX_EVENTS y usar EPOLLET no dio un aumento significativo en el rendimiento. Pero ¿qué pasa si aumentas el número de conexiones simultáneas?
Se obtuvo el resultado deseado, y con él un interesante gráfico que muestra la dependencia del número de solicitudes procesadas en 1 minuto del número de conexiones:
Vemos que después de un par de cientos de conexiones, la cantidad de solicitudes procesadas para ambos servidores cae drásticamente (en la versión multiproceso esto es más notable). ¿Está esto relacionado con la implementación de la pila TCP/IP de Linux? No dude en escribir sus suposiciones sobre este comportamiento del gráfico y las optimizaciones para opciones de subprocesos múltiples y de un solo subproceso en los comentarios.
cómo señaló En los comentarios, esta prueba de rendimiento no muestra el comportamiento del reactor de E/S bajo cargas reales, porque casi siempre el servidor interactúa con la base de datos, genera registros, utiliza criptografía con TLS etc., como resultado de lo cual la carga se vuelve no uniforme (dinámica). Las pruebas junto con componentes de terceros se realizarán en el artículo sobre el proactor de E/S.
Desventajas del reactor de E/S
Debe comprender que el reactor de E/S no está exento de inconvenientes, a saber:
Usar un reactor de E/S en un entorno de subprocesos múltiples es algo más difícil, porque Tendrás que gestionar manualmente los flujos.
La práctica muestra que en la mayoría de los casos la carga no es uniforme, lo que puede provocar que un subproceso se registre mientras otro está ocupado con el trabajo.
Si un controlador de eventos bloquea un hilo, entonces el selector del sistema también lo bloqueará, lo que puede provocar errores difíciles de encontrar.
Resuelve estos problemas proactor de E/S, que a menudo tiene un programador que distribuye uniformemente la carga a un grupo de subprocesos y también tiene una API más conveniente. Hablaremos de ello más adelante, en mi otro artículo.
Conclusión
Aquí termina nuestro viaje desde la teoría hasta el escape perfilador.
No debería insistir en esto, porque existen muchos otros enfoques igualmente interesantes para escribir software de red con diferentes niveles de conveniencia y velocidad. Interesantes, en mi opinión, se proporcionan enlaces a continuación.